Skip to content

Cancel Snowflake queries when a user kills the deferred task#69635

Open
steveahnahn wants to merge 3 commits into
apache:mainfrom
steveahnahn:snowflake-cancel-queries-on-user-kill-deferred
Open

Cancel Snowflake queries when a user kills the deferred task#69635
steveahnahn wants to merge 3 commits into
apache:mainfrom
steveahnahn:snowflake-cancel-queries-on-user-kill-deferred

Conversation

@steveahnahn

@steveahnahn steveahnahn commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Problem

  • SnowflakeSqlApiOperator.on_kill cancels the running queries, but after defer() the worker is gone and it can never run.
  • SnowflakeSqlApiTrigger had no on_kill (base default is a no op), so killing a deferred task leaves the statements executing on the warehouse, burning credits.
  • Sibling triggers already cancel on user kill: EMR in the Amazon provider, BigQuery/Dataproc/Dataflow in the Google provider.

Change

  • Add on_kill to SnowflakeSqlApiTrigger: cancels the running query ids.
  • The SQL API cancel is a blocking POST with no async variant, so on_kill runs cancel_queries through sync_to_async, building the hook inside that worker so no connection work touches the event loop.
  • cancel_on_kill flag (default True) on operator and trigger; operator threads it into the trigger at the defer site.
  • Best effort: cancel failures are logged and swallowed. Fires only on user kill, never on triggerer restart, redistribution, or timeout.

Live verification

  1. Deferrable operator submitted a statement; task deferred, trigger polling (GET /api/v2/statements/{id} returning 202).

deferred task, trigger polling the statement

  1. Marked the task failed from the UI. Task log:
Trigger cancelled by user action, invoking on_kill
Cancelling Snowflake query ids ['069b68f4-8e5e-404a-aef9-0eca86eb396d']
Snowflake query ids ['069b68f4-8e5e-404a-aef9-0eca86eb396d'] cancelled

task failed, on_kill cancelling the Snowflake query

  1. The endpoint received POST /api/v2/statements/{id}/cancel and the statement transitioned to ABORTED. Without the change it stays running.

Tests

  • Trigger: cancel on kill, disabled/empty id no ops, error swallowing, serialization of the flag.
  • Operator: cancel_on_kill=False guard, flag threaded into the trigger on defer.
  • Every new assertion fails without the source change.

Was generative AI tooling used to co-author this PR?
  • Yes, Claude Code (Fable 5)

Generated-by: Claude Code (Fable 5) following the guidelines

@boring-cyborg boring-cyborg Bot added area:providers provider:snowflake Issues related to Snowflake provider labels Jul 9, 2026
@steveahnahn steveahnahn closed this Jul 9, 2026
@steveahnahn steveahnahn reopened this Jul 10, 2026
@steveahnahn
steveahnahn force-pushed the snowflake-cancel-queries-on-user-kill-deferred branch from a7c612b to a878441 Compare July 10, 2026 19:40
@steveahnahn
steveahnahn marked this pull request as ready for review July 10, 2026 22:57
@steveahnahn
steveahnahn requested a review from potiuk as a code owner July 10, 2026 22:57
@potiuk potiuk added the ready for maintainer review Set after triaging when all criteria pass. label Jul 11, 2026
@steveahnahn
steveahnahn force-pushed the snowflake-cancel-queries-on-user-kill-deferred branch 2 times, most recently from 0bad94d to fbb14ac Compare July 16, 2026 22:28

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice change — this closes a real gap, and threading cancel_on_kill through the defer site so the trigger inherits the operator's setting is the right shape.

I verified locally: the 57 trigger + operator tests pass, the async def on_kill signature matches BaseTrigger.on_kill (the triggerer awaits it, so a sync override would have silently never run), and the positional SnowflakeSqlApiHook(conn_id, token_life_time, token_renewal_delta) args match the constructor. Building the hook inside the sync_to_async worker rather than on the event loop is the correct call. Serialization round-trip coverage and the live-verification screenshots are appreciated.

One thing to fix and two I'd like your read on — left inline. Only the asgiref one is blocking.


Drafted-by: Claude Code (Opus 4.8); reviewed by @potiuk before posting

from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any

from asgiref.sync import sync_to_async

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first and only asgiref usage in the Snowflake provider, and asgiref isn't in this provider's dependencies in providers/snowflake/pyproject.toml. It resolves today only transitively via apache-airflow, but providers should declare what they import.

Amazon and Google both do exactly this where they use sync_to_async:

"asgiref>=2.3.0; python_version < '3.14'",
"asgiref>=3.11.1; python_version >= '3.14'",

Worth carrying over the >=3.11.1 floor for 3.14 rather than a bare pin.


Drafted-by: Claude Code (Opus 4.8); reviewed by @potiuk before posting

self.token_life_time,
self.token_renewal_delta,
)
hook.cancel_queries(self.query_ids)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SnowflakeSqlApiHook.cancel_queries loops _cancel_sql_api_query_execution per id with no per-query error handling, and on_kill catches only at the outermost level — so the first id that raises aborts cancellation of every id after it.

That matters most in exactly the case this PR targets: run() polls statements sequentially and never prunes self.query_ids, so on a multi-statement operator the earlier statements have typically already completed by kill time. If cancelling a completed statement raises, the still-running later statements — the ones actually burning credits — never get cancelled.

Mitigating factor: _process_response returns a dict for HTTP 422 rather than raising, so if Snowflake answers 422 for "not running" the loop survives fine. But a 404 on a reaped handle, or a 401 on token expiry, would raise.

Did your live test cover killing a multi-statement task where some statements had already finished? If 422 is what Snowflake actually returns there, this is a non-issue and worth a one-line comment saying so. If not, a per-query try/except in _cancel_queries would make it genuinely best-effort. I didn't want to assert a bug I couldn't reproduce without live credentials.


Drafted-by: Claude Code (Opus 4.8); reviewed by @potiuk before posting

return
self.log.info("Cancelling Snowflake query ids %s", self.query_ids)
try:
await sync_to_async(self._cancel_queries)()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, more of a docs note: the triggerer wraps this in asyncio.wait_for(trigger.on_kill(), timeout=_ON_CANCEL_TIMEOUT)[triggerer] on_kill_timeout, default 30s.

Cancellation here is N sequential blocking POSTs, each with tenacity retries on 429/503/504. A task with many statements against a slow warehouse could exceed that budget, and because sync_to_async runs in a thread the remaining cancels can't be interrupted — you'd get an on_kill() timed out warning while the thread keeps going.

Benign in practice, but the docstring's "best effort" framing might be worth extending to mention the timeout so operators know the knob exists.


Drafted-by: Claude Code (Opus 4.8); reviewed by @potiuk before posting

A deferred SnowflakeSqlApiOperator parks its running query ids in the triggerer,
so the operator's own on_kill no longer runs once the task is deferred. When a
user marks that task failed, clears it, or marks it success, SnowflakeSqlApiTrigger
had no on_kill hook, so the Snowflake statements kept executing on the warehouse,
burning compute credits, even though the operator already cancels the queries on
kill in the non-deferred path.

This adds on_kill to SnowflakeSqlApiTrigger to cancel the running query ids when
the user acts on the deferred task, matching the behaviour already shipped for the
EMR, Dataproc, BigQuery, and Dataflow triggers. The Snowflake SQL API cancel is a
blocking POST with no async variant, so it runs through sync_to_async off the
triggerer event loop, and the hook is built inside that worker so no connection
work touches the loop. A cancel_on_kill flag on both the operator and the trigger
lets users opt out.
@steveahnahn
steveahnahn force-pushed the snowflake-cancel-queries-on-user-kill-deferred branch from 44bf75c to 50362e6 Compare July 26, 2026 00:54
A single failing cancel (a reaped statement handle, a transient error)
would otherwise stop the remaining ids from being cancelled, leaving the
still-running statements that are actually consuming credits untouched.

Declare asgiref, which the trigger imports directly rather than relying
on it resolving transitively through apache-airflow.
@steveahnahn
steveahnahn force-pushed the snowflake-cancel-queries-on-user-kill-deferred branch from 50362e6 to 687272a Compare July 26, 2026 03:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providers provider:snowflake Issues related to Snowflake provider ready for maintainer review Set after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants